SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
14.7 KB · 295 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound, permanentRedirect } from 'next/navigation';4import { ExternalLink } from 'lucide-react';5import { GraphLink } from '@/components/graph/graph-link';6import { PageHeader, Section, KV, Note } from '@/components/ui/section';7import { Badge, ClaimBadge, MatchBadge, StatusBadge } from '@/components/ui/badge';8import { EmptyState } from '@/components/ui/empty-state';9import { Freshness } from '@/components/ui/freshness';10import { SourceBadge } from '@/components/ui/source-badge';11import { JsonView } from '@/components/ui/json-view';12import { PublicationList } from '@/components/data/publication-list';13import { Pager } from '@/components/ui/pager';14import { getTrialByNct, trialConditionsFor, trialInterventionsFor, trialLocationsByCountry } from '@/lib/queries/trials';15import { publicationsForTrial, publicationsForTrialCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications';16import { fmtDate, fmtInt, humanize, phaseLabel } from '@/lib/format';17import { pageInfo } from '@/lib/pagination';18import { int, type SP } from '@/lib/search-params';1920export const revalidate = 3600;2122export async function generateMetadata({ params }: { params: Promise<{ nct: string }> }): Promise<Metadata> {23  const t = await getTrialByNct((await params).nct);24  return t ? { title: `${t.nct_id} — ${t.brief_title}`, description: t.brief_summary ? t.brief_summary.slice(0, 160) : `${t.nct_id}: status, phase, conditions, interventions, locations and references.`, alternates: { canonical: `/trial/${t.nct_id}` } } : { title: 'Trial' };25}2627export default async function TrialPage({ params, searchParams }: { params: Promise<{ nct: string }>; searchParams: Promise<SP> }) {28  const { nct } = await params;29  const t = await getTrialByNct(nct);30  if (!t) notFound();31  if (t.nct_id !== nct) permanentRedirect(`/trial/${t.nct_id}`);32  const sp = await searchParams;33  const pubTotal = await publicationsForTrialCount(t.nct_id);34  const pp = pageInfo(int(sp, 'pPage', 1, 1, 100_000), PUBLICATION_PAGE_SIZE, pubTotal);35  const [conditions, interventions, locations, pubs] = await Promise.all([trialConditionsFor(t.id), trialInterventionsFor(t.id), trialLocationsByCountry(t.id), pubTotal ? publicationsForTrial(t.nct_id, { page: pp.page, pageSize: pp.pageSize }) : Promise.resolve([])]);36  const elig = t.eligibility ?? {};37  const eligText = typeof elig.criteria === 'string' ? elig.criteria : typeof elig.eligibilityCriteria === 'string' ? elig.eligibilityCriteria : null;3839  return (40    <article>41      <PageHeader kicker={`Clinical trial · ${t.study_type ? humanize(t.study_type) : 'study'}`} title={t.brief_title} lede={t.official_title && t.official_title !== t.brief_title ? t.official_title : undefined}>42        <div className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">43          <span className="ci-mono text-ink-2">{t.nct_id}</span>44          <span className="ci-mono text-ink-3">{t.id}</span>45          {t.acronym ? <Badge tone="outline">{t.acronym}</Badge> : null}46          <StatusBadge status={t.overall_status} />47          {t.phases.length ? <Badge>{t.phases.map(phaseLabel).join(' / ')}</Badge> : null}48          {t.has_results ? <Badge tone="ok">Results posted</Badge> : null}49          <a className="ci-link inline-flex items-center gap-1" href={`https://clinicaltrials.gov/study/${t.nct_id}`} target="_blank" rel="noopener noreferrer">50            ClinicalTrials.gov <ExternalLink className="h-3 w-3" aria-hidden />51          </a>52          <SourceBadge p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', retrievedAt: t.updated_at, ingestRunId: t.ingest_run_id, layer: 'normalized' }} />53          <ClaimBadge kind="published" />54          <GraphLink type="trial" entityRef={t.nct_id} />55        </div>56        {t.why_stopped ? <Note tone="warn">Why stopped (as posted): {t.why_stopped}</Note> : null}57      </PageHeader>5859      <div className="grid gap-8 lg:grid-cols-[1fr_340px]">60        <div className="space-y-8">61          {t.brief_summary ? (62            <Section id="summary" kicker="Summary" title="Brief summary (as posted)">63              <p className="max-w-3xl whitespace-pre-line text-[14.5px] leading-relaxed">{t.brief_summary}</p>64            </Section>65          ) : null}6667          <Section id="conditions" kicker="Conditions" title={`Conditions (${fmtInt(conditions.length || t.conditions.length)})`} description="Free-text conditions as registered, with the CancerIndex entity they were reconciled to and the match type.">68            {conditions.length ? (69              <div className="ci-table-wrap">70                <table className="ci-table">71                  <thead>72                    <tr>73                      <th scope="col">Condition (as posted)</th>74                      <th scope="col">Mapped entity</th>75                      <th scope="col">Match</th>76                      <th scope="col" className="num">77                        Confidence78                      </th>79                    </tr>80                  </thead>81                  <tbody>82                    {conditions.map((c) => (83                      <tr key={c.normalized}>84                        <td>{c.condition_text}</td>85                        <td>86                          {c.cancer_slug ? (87                            <Link className="ci-link" href={`/cancer/${c.cancer_slug}/trials`}>88                              {c.cancer_name}89                            </Link>90                          ) : (91                            <span className="text-ink-3">—</span>92                          )}93                        </td>94                        <td>95                          <MatchBadge matchType={c.match_type} />96                        </td>97                        <td className="num">{c.confidence != null ? c.confidence.toFixed(2) : '—'}</td>98                      </tr>99                    ))}100                  </tbody>101                </table>102              </div>103            ) : t.conditions.length ? (104              <ul className="flex flex-wrap gap-1.5 text-[13.5px]">105                {t.conditions.map((c) => (106                  <li key={c} className="border border-rule px-2 py-0.5">107                    {c} <MatchBadge matchType="UNRESOLVED" className="ml-1" />108                  </li>109                ))}110              </ul>111            ) : (112              <EmptyState compact>No condition recorded.</EmptyState>113            )}114          </Section>115116          <Section id="interventions" kicker="Interventions" title={`Interventions (${fmtInt(interventions.length || t.interventions.length)})`}>117            {interventions.length ? (118              <div className="ci-table-wrap">119                <table className="ci-table">120                  <thead>121                    <tr>122                      <th scope="col">Intervention</th>123                      <th scope="col">Type</th>124                      <th scope="col">Mapped drug</th>125                      <th scope="col">Match</th>126                    </tr>127                  </thead>128                  <tbody>129                    {interventions.map((i) => (130                      <tr key={i.name}>131                        <td>{i.name}</td>132                        <td>{i.intervention_type ? <Badge>{humanize(i.intervention_type)}</Badge> : '—'}</td>133                        <td>134                          {i.drug_slug ? (135                            <Link className="ci-link" href={`/drug/${i.drug_slug}`}>136                              {i.drug_name}137                            </Link>138                          ) : (139                            <span className="text-ink-3">—</span>140                          )}141                        </td>142                        <td>143                          <MatchBadge matchType={i.match_type} />144                        </td>145                      </tr>146                    ))}147                  </tbody>148                </table>149              </div>150            ) : t.interventions.length ? (151              <ul className="space-y-1 text-[13.5px]">152                {t.interventions.map((i, k) => (153                  <li key={`${i.name}-${k}`}>154                    <Badge className="mr-1">{humanize(i.type)}</Badge> {i.name}155                    {i.description ? <span className="block text-[12.5px] text-ink-3">{i.description}</span> : null}156                  </li>157                ))}158              </ul>159            ) : (160              <EmptyState compact>No intervention recorded.</EmptyState>161            )}162          </Section>163164          {t.arms.length || t.primary_outcomes.length ? (165            <Section id="design" kicker="Design" title="Arms and outcomes">166              <div className="grid gap-4 md:grid-cols-2">167                <div>168                  <p className="ci-kicker mb-1">Arms ({t.arms.length})</p>169                  <JsonView data={t.arms} />170                </div>171                <div>172                  <p className="ci-kicker mb-1">Primary outcomes ({t.primary_outcomes.length})</p>173                  <JsonView data={t.primary_outcomes} />174                  {t.secondary_outcomes.length ? (175                    <details className="mt-2 text-[12.5px]">176                      <summary className="ci-link">Secondary outcomes ({t.secondary_outcomes.length})</summary>177                      <JsonView data={t.secondary_outcomes} />178                    </details>179                  ) : null}180                </div>181              </div>182            </Section>183          ) : null}184185          <Section id="eligibility" kicker="Eligibility" title="Eligibility (as posted)">186            <KV items={[{ k: 'Sex', v: t.sex ? humanize(t.sex) : null }, { k: 'Minimum age', v: t.minimum_age }, { k: 'Maximum age', v: t.maximum_age }]} />187            {eligText ? (188              <details className="mt-3">189                <summary className="ci-link text-[13.5px]">Show eligibility criteria text</summary>190                <pre className="ci-code mt-2 whitespace-pre-wrap">{eligText}</pre>191              </details>192            ) : Object.keys(elig).length ? (193              <details className="mt-3">194                <summary className="ci-link text-[13.5px]">Show eligibility record</summary>195                <JsonView data={elig} />196              </details>197            ) : (198              <p className="mt-2 text-[13px] text-ink-3">No eligibility text recorded.</p>199            )}200          </Section>201202          <Section id="references" kicker="References" title={`Publications (${fmtInt(pubTotal || t.references.length)})`}>203            {pubs.length ? (204              <>205                <PublicationList206                  rows={pubs}207                  summary={208                    <>209                      Showing {fmtInt(pp.from)}–{fmtInt(pp.to)} of {fmtInt(pubTotal)} indexed publications citing this registration210                    </>211                  }212                />213                <Pager total={pubTotal} pageSize={pp.pageSize} page={pp.page} hrefFor={(p) => `/trial/${t.nct_id}${p > 1 ? `?pPage=${p}` : ''}#references`} label="Publication pages" noun="publications" />214              </>215            ) : null}216            {t.references.length ? (217              <ul className="mt-2 space-y-1 text-[13px]">218                {t.references.map((r, i) => (219                  <li key={`${r.pmid ?? i}`} className="text-ink-2">220                    {r.type ? <Badge tone="outline" className="mr-1">{r.type}</Badge> : null}221                    {r.citation ?? ''}222                    {r.pmid ? (223                      <>224                        {' '}225                        <Link className="ci-mono ci-link" href={`/publication/${r.pmid}`}>226                          PMID {r.pmid}227                        </Link>228                      </>229                    ) : null}230                  </li>231                ))}232              </ul>233            ) : !pubs.length ? (234              <EmptyState compact>No reference posted for this study.</EmptyState>235            ) : null}236          </Section>237        </div>238239        <aside className="space-y-8">240          <Section id="dates" kicker="Registry" title="Dates and enrollment" level={3}>241            <KV242              items={[243                { k: 'Start', v: fmtDate(t.start_date) },244                { k: 'Primary completion', v: fmtDate(t.primary_completion_date) },245                { k: 'Completion', v: fmtDate(t.completion_date) },246                { k: 'First posted', v: fmtDate(t.first_posted_date) },247                { k: 'Last update posted', v: fmtDate(t.last_update_posted_date) },248                { k: 'Results first posted', v: t.results_first_posted_date ? fmtDate(t.results_first_posted_date) : null },249                { k: 'Enrollment', v: t.enrollment_count != null ? <span className="ci-num">{fmtInt(t.enrollment_count)} {t.enrollment_type ? `(${t.enrollment_type.toLowerCase()})` : ''}</span> : null },250                { k: 'Lead sponsor', v: t.lead_sponsor ? <>{t.lead_sponsor} {t.lead_sponsor_class ? <Badge tone="outline">{t.lead_sponsor_class}</Badge> : null}</> : null },251                { k: 'Collaborators', v: t.collaborators.length ? t.collaborators.join('; ') : null },252                { k: 'Keywords', v: t.keywords.length ? t.keywords.join(', ') : null },253              ]}254            />255            <Freshness dataUpdatedAt={t.updated_at} sourceUpdatedAt={t.last_update_posted_date} extra="source: clinicaltrials" />256          </Section>257          <Section id="locations" kicker="Locations" title={`Locations by country (${fmtInt(t.locations_count)})`} level={3}>258            {locations.length ? (259              <div className="ci-table-wrap">260                <table className="ci-table">261                  <thead>262                    <tr>263                      <th scope="col">Country</th>264                      <th scope="col" className="num">265                        Sites266                      </th>267                      <th scope="col" className="num">268                        Recruiting269                      </th>270                    </tr>271                  </thead>272                  <tbody>273                    {locations.map((l) => (274                      <tr key={l.country}>275                        <td>{l.country}</td>276                        <td className="num">{fmtInt(l.n)}</td>277                        <td className="num">{fmtInt(l.recruiting)}</td>278                      </tr>279                    ))}280                  </tbody>281                </table>282              </div>283            ) : t.countries.length ? (284              <p className="text-[13.5px]">{t.countries.join(', ')}</p>285            ) : (286              <p className="text-[13px] text-ink-3">No location posted.</p>287            )}288          </Section>289          <Note tone="warn">Trial listing is informational. Eligibility is decided by the study team; contact information is on ClinicalTrials.gov.</Note>290        </aside>291      </div>292    </article>293  );294}295